// Copyright (C) 2007-2009 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.enterprise.connector.otex;

import java.io.InputStream;
import java.util.Calendar;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.google.enterprise.connector.spi.Document;
import com.google.enterprise.connector.spi.DocumentList;
import com.google.enterprise.connector.spi.RepositoryException;
import com.google.enterprise.connector.spi.RepositoryDocumentException;
import com.google.enterprise.connector.spi.SpiConstants;
import com.google.enterprise.connector.spi.SpiConstants.ActionType;
import com.google.enterprise.connector.spi.TraversalContext;
import com.google.enterprise.connector.spi.Value;
import com.google.enterprise.connector.otex.client.Client;
import com.google.enterprise.connector.otex.client.ClientFactory;
import com.google.enterprise.connector.otex.client.ClientValue;
import com.google.enterprise.connector.otex.client.ClientValueFactory;

/*
 * We use inner classes here to share the recarray and other fields in
 * <code>LivelinkDocumentList</code> with nested classes.
 */
class LivelinkDocumentList implements DocumentList {
  /** The logger for this class. */
  private static final Logger LOGGER =
      Logger.getLogger(LivelinkDocumentList.class.getName());

  /** An immutable false value. */
  private static final Value VALUE_FALSE = Value.getBooleanValue(false);

  /** An immutable true value. */
  private static final Value VALUE_TRUE = Value.getBooleanValue(true);

  /** ObjectInfo and VersionInfo assoc fields that hold UserId or GroupId. */
  private static final String[] USER_FIELD_NAMES = {
    "UserID", "GroupID", "AssignedTo", "CreatedBy", "ReservedBy",
    "LockedBy", "Owner" };

  /** Date formatter used to construct checkpoint dates */
  private final LivelinkDateFormat dateFormat =
      LivelinkDateFormat.getInstance();

  /** The connector contains configuration information. */
  private final LivelinkConnector connector;

  /** The client provides access to the server. */
  private final Client client;

  /** A concrete strategy for retrieving the content from the server. */
  private final ContentHandler contentHandler;

  /** The concrete ClientValue implementation associated with this Client. */
  private final ClientValueFactory valueFactory;

  /** The table of Livelink data, one row per doc, one column per field. */
  private final ClientValue recArray;

  /** The recarray fields. */
  private final Field[] fields;

  /** The table of Livelink deleted data, one row per object */
  private final ClientValue delArray;

  /** The TraversalContext from TraversalContextAware Interface */
  private final TraversalContext traversalContext;

  /** The current checkpoint reflecting the cursor in the DocumentList */
  private final Checkpoint checkpoint;

  /** If the traversal client user is the public content user,
   *  then all documents it sees will be publicly available.
   */
  private boolean isPublicContentUser = false;

  /** This subset of documents are authorized as public content. */
  private HashSet<String> publicContentDocs = null;

  /** This is the DocumentList Iterator */
  private final LivelinkDocumentListIterator docIterator;

  /** Count of documents returned in this batch. */
  private int docsReturned;

  /** This is a small cache of UserID and GroupID name resolutions. */
  private final HashMap<Integer, ClientValue> userNameCache =
      new HashMap<Integer, ClientValue>(200);

  /**
   * Constructor for non-trivial document set.  Iterate over a
   * RecArray of items returned from Livelink.
   */
  LivelinkDocumentList(LivelinkConnector connector, Client client,
      ContentHandler contentHandler, ClientValue recArray,
      Field[] fields, ClientValue delArray,
      TraversalContext traversalContext, Checkpoint checkpoint,
      String currentUsername) throws RepositoryException {
    this.connector = connector;
    this.client = client;
    this.valueFactory = client.getClientValueFactory();
    this.contentHandler = contentHandler;
    this.recArray = recArray;
    this.delArray = delArray;
    this.fields = fields;
    this.traversalContext = traversalContext;
    this.checkpoint = checkpoint;

    // Subset the docIds in the recArray into Public and Private Docs.
    findPublicContent(currentUsername);

    // Prime the DocumentList.nextDocument() iterator
    docIterator = new LivelinkDocumentListIterator();
    docsReturned = 0;
  }

  /**
   * Constructor for an empty document list.
   */
  LivelinkDocumentList(Checkpoint checkpoint) {
    this.checkpoint = checkpoint;
    this.docIterator = null;
    this.connector = null;
    this.client = null;
    this.valueFactory = null;
    this.contentHandler = null;
    this.recArray = null;
    this.delArray = null;
    this.fields = null;
    this.traversalContext = null;
  }

  /**
   * {@inheritDoc}
   */
  public Document nextDocument() throws RepositoryException {
    if (docIterator != null && docIterator.hasNext()) {
      // The Connector Manager does not handle individual document
      // failures very well.  If processing a document throws an
      // exception, we will try to determine if the failure is
      // transient (like server not responding), or permanent
      // (like the document is corrupt and can never be fetched).
      // In the case of transient errors, we want to provide a
      // checkpoint that allows the failed document to be retried
      // at a later time.  In the case of a permanent failure for
      // a document, we want to simply skip it and go onto the next.
      try {
        Document doc = docIterator.nextDocument();
        docsReturned++;
        return doc;
      } catch (Throwable t) {
        LOGGER.severe("Caught exception when fetching a document: " +
            t.getMessage());

        // Ping the Livelink Server.  If I can't talk to the server,
        // I will consider this a transient Exception (server down,
        // network error, etc).  In that case, throw a
        // RepositoryException up to the Connector Manager,
        // signaling the end of this batch.  The CM will retry later.
        try {
          client.GetCurrentUserID();  // ping()
        } catch (RepositoryException e) {
          // The failure seems to be systemic, rather than a problem
          // with this particular document.  Restore the previous
          // checkpoint, so that we may retry this document on the
          // next pass.
          checkpoint.restore();
          throw e;
        }

        // It does not appear to be a transient failure.  Assume this
        // document contains permanent failures, and skip over it.
        throw new RepositoryDocumentException(t);
      }
    }

    // No more documents available.
    checkpoint.advanceToEnd();
    return null;
  }

  /**
   * {@inheritDoc}
   */
  public String checkpoint() throws RepositoryException {
    String cp = checkpoint.toString();

    if (LOGGER.isLoggable(Level.FINE))
      LOGGER.fine("CHECKPOINT: " + cp);

    return cp;
  }

  /**
   * If we have a Public Content User specified, some of the
   * documents in the repository may be available to the public.
   * If the current user *is* the public content user, then all
   * documents in the recArray are by definition available to the
   * public.  However if the current user is not the public user,
   * we must subset the documents into those that are public and
   * those that are not.
   *
   * XXX: This gets access to the AuthorizationManager via the
   * Connector's ClientFactory.  It should really get it from the
   * Session, but at this point we don't know what session we
   * belong to.
   *
   * @param currentUsername the currently logged in user; may be impersonated
   */
  private void findPublicContent(String currentUsername)
      throws RepositoryException {
    String pcuser = connector.getPublicContentUsername();
    if (pcuser != null && pcuser.length() > 0) {
      isPublicContentUser = pcuser.equals(currentUsername);
      if (!isPublicContentUser) {
        // Get the subset of the DocIds that have public access.
        ClientFactory clientFactory = connector.getClientFactory();
        LivelinkAuthorizationManager authz;
        authz = new LivelinkAuthorizationManager(connector, clientFactory);
        publicContentDocs = new HashSet<String>();
        authz.addAuthorizedDocids(new DocIdIterator(), pcuser,
            publicContentDocs);

        // We only care if there actually are public docs in
        // this batch.
        if (publicContentDocs.isEmpty())
          publicContentDocs = null;
      }
    }
  }

  /**
   * This iterates over the DocIDs in the recArray.
   */
  private class DocIdIterator implements Iterator<String> {
    /** The current row of the recarray. */
    private int row;

    /** The size of the recarray. */
    private final int size;

    public DocIdIterator() {
      this.row = 0;
      this.size = recArray.size();
    }

    public boolean hasNext() {
      return row < size;
    }

    public String next() {
      if (row < size) {
        try {
          return recArray.toString(row, "DataID");
        } catch (RepositoryException e) {
          LOGGER.warning("LivelinkDocumentList.DocIdIterator.next " +
              "caught exception - " + e.getMessage());
          throw new RuntimeException(e);
        } finally {
          row++;
        }
      }
      return null;
    }

    public void remove() {
      throw new UnsupportedOperationException();
    }
  }

  /**
   * Iterates over a <code>DocumentList</code>, returning each
   * <code>Document</code> it contains.
   */
  /*
   * TODO: Eliminate this iterator and implement nextDocument
   * directly using this code. The gap seems to be just that
   * LivelinkTest assumes that it can iterate over the DocumentList
   * multiple times, which the new SPI doesn't allow. We could redo
   * the tests to eliminate that requirement, or add an internal
   * reset/restart/beforeFirst method to cause nextDocument to start
   * over again at the beginning.
   */
  private class LivelinkDocumentListIterator {
    /** The current row of the recArray. */
    private int insRow;

    /** The size of the recArray. */
    private final int insSize;

    /** The current row of the delArray. */
    private int delRow;

    /** The size of the delArray. */
    private final int delSize;

    /** The object ID of the current row. */
    private int objectId;

    /** The volume ID of the current row. */
    private int volumeId;

    /** The object Subtype. */
    private int subType;

    /** The ObjectInfo of the current row, [fetch delayed until needed] */
    private ClientValue objectInfo;

    /** The VersionInfo of the current row, [fetch delayed until needed] */
    private ClientValue versionInfo;

    /** The Document Properties associated with the current row. */
    private LivelinkDocument props;

    /** The set of categories to include. */
    private HashSet<Object> includedCategories;

    /** The set of categories to exclude. */
    private HashSet<Object> excludedCategories;

    /** Whether to even bother with Category attributes? */
    private final boolean doCategories;

    /** Index only category attributes that are marked searchable? */
    private final boolean includeSearchable;

    /** Include category names as properties? */
    private final boolean includeCategoryNames;

    LivelinkDocumentListIterator() {
      this.delRow = 0;
      this.delSize = (delArray == null) ? 0 : delArray.size();

      this.insRow = 0;
      this.insSize = (recArray == null) ? 0 : recArray.size();

      if (insSize > 0) {
        // Fetch the set of categories to include and exclude.
        this.includedCategories = connector.getIncludedCategories();
        this.excludedCategories = connector.getExcludedCategories();

        // Should we index any Category attributes at all?
        this.doCategories = !(includedCategories.contains("none") ||
            excludedCategories.contains("all"));

        // Set qualifiers on the included category attributes.
        this.includeSearchable = includedCategories.contains("searchable");
        this.includeCategoryNames = includedCategories.contains("name");

        // If we index all Categories, don't bother searching the set,
        // as it will only slow us down.
        if (includedCategories.contains("all"))
          includedCategories = null;

        // If we exclude no Categories, don't bother searching the set,
        // as it will only slow us down.
        if (excludedCategories.contains("none"))
          excludedCategories = null;
      } else {
        this.doCategories = false;
        this.includeSearchable = false;
        this.includeCategoryNames = false;
        this.includedCategories = null;
        this.excludedCategories = null;
      }
    }

    public boolean hasNext() {
      return (insRow < insSize) || (delRow < delSize);
    }

    public LivelinkDocument nextDocument() throws RepositoryException {
      if (!hasNext())
        return null;

      // Walk two separate lists: inserts and deletes.
      // Process the request in date order.  If an insert and
      // a delete have the same date, process inserts first.
      Date insDate = null, delDate = null;
      int dateComp = 0;
      objectInfo = null;
      versionInfo = null;

      // Peek at the next item to insert,
      if (insRow < insSize) {
        try {
          insDate = recArray.toDate(insRow, "ModifyDate");
        } catch (RepositoryException e1) {
          insRow++;
          throw e1;
        }
      } else
        dateComp = 1;

      // ... and the next item to delete.
      if (delRow < delSize) {
        try {
          delDate = dateFormat.parse(delArray.toString(delRow,
                  "AuditDate"));
        } catch (RepositoryException e1) {
          delRow++;
          throw e1;
        }
      } else
        dateComp = -1;

      // Process earlier modification/deletion times first.
      // If timestamps are the same, process Inserts before Deletes.
      // This way, if an item is added to the Livelink DB
      // then deleted immediately we will be sure to process
      // the insert before the delete.
      if (dateComp == 0)
        dateComp = insDate.compareTo(delDate);

      if (dateComp <= 0) {
        try {
          // Return an Inserted Item.
          objectId = recArray.toInteger(insRow, "DataID");
          volumeId = recArray.toInteger(insRow, "OwnerID");
          subType  = recArray.toInteger(insRow, "Subtype");

          props = new LivelinkDocument(objectId, fields.length*2);

          // Collect the various properties for this row.
          collectRecArrayProperties();
          collectObjectInfoProperties();
          collectVersionProperties();
          collectCategoryAttributes();
          collectDerivedProperties();
        } finally {
          // Establish the checkpoint for this row.
          checkpoint.setInsertCheckpoint(insDate, objectId);
          insRow++;
        }
      } else {
        try {
          // return a Deleted Item
          objectId = delArray.toInteger(delRow, "DataID");

          // LOGGER.fine("Deleted item[" + delRow + "]: DataID " +
          //     objectId + "   AuditDate " + delDate +
          //     "    EventID " +
          //     delArray.toValue(delRow, "EventID").toString2());
          props = new LivelinkDocument(objectId, 3);
          collectDeletedObjectAttributes(delDate);
        } finally {
          // Establish the checkpoint for this row.
          checkpoint.setDeleteCheckpoint(delDate,
              delArray.toValue(delRow, "EventID"));
          delRow++;
        }
      }

      return props;
    }

    /**
     * For items to be deleted from the index, we need only supply
     * the GSA the DocId, lastModified date, and a Delete action
     * properties.
     */
    private void collectDeletedObjectAttributes(Date deleteDate)
        throws RepositoryException
    {
      props.addProperty(SpiConstants.PROPNAME_DOCID,
          Value.getLongValue(objectId));
      Calendar c = Calendar.getInstance();
      c.setTime(deleteDate); // sic; we have milliseconds here.
      props.addProperty(SpiConstants.PROPNAME_LASTMODIFIED,
          Value.getDateValue(c));
      props.addProperty(SpiConstants.PROPNAME_ACTION,
          Value.getStringValue(ActionType.DELETE.toString()));
    }

    /** Collects the recarray-based properties. */
    /*
     * TODO: Undefined values will not be added to the property
     * map. We may want some other value, possibly different
     * default values for each column (e.g., MimeType =
     * "application/octet-stream").
     */
    private void collectRecArrayProperties() throws RepositoryException {
      for (int i = 0; i < fields.length; i++) {
        if (fields[i].propertyNames.length > 0) {
          ClientValue value =
              recArray.toValue(insRow, fields[i].fieldName);
          if (value.isDefined()) {
            if (isUserIdOrGroupId(fields[i].fieldName)) {
              // FIXME: hack knows that UserID has 1 propertyName
              addUserByName(fields[i].propertyNames[0], value);
            } else
              props.addProperty(fields[i], value);
          }
        }
      }
    }

    /** Collects additional properties derived from the recarray. */
    private void collectDerivedProperties() throws RepositoryException {
      // Flag the document as publicly accessible (or not).
      boolean isPublic = isPublicContentUser ||
          (publicContentDocs != null &&
              publicContentDocs.contains(Integer.toString(objectId)));
      props.addProperty(SpiConstants.PROPNAME_ISPUBLIC,
          isPublic ? VALUE_TRUE : VALUE_FALSE);

      // Fetch the content.
      collectContentProperty();

      // Add the ExtendedData as MetaData properties.
      collectExtendedDataProperties();

      // DISPLAYURL
      String displayUrl = isPublic ?
          connector.getPublicContentDisplayUrl() :
          connector.getDisplayUrl();
      String url = connector.getDisplayUrl(displayUrl,
          subType, objectId, volumeId, getDownloadFileName());
      props.addProperty(SpiConstants.PROPNAME_DISPLAYURL,
          Value.getStringValue(url));
    }

    /**
     * Construct a download filename based upon the object name,
     * its mimetype, and the filename of its version.  The download
     * name is URL safe, containing no whitespaces or special characters.
     *
     * @return download filename, may be null.
     */
    private String getDownloadFileName() {
      String fileName = null;

      // If we have content, try to include a filename in the displayUrl.
      try {
        // We're guessing here that if MimeType is
        // non-null then there should be a content.
        if (!recArray.isDefined(insRow, "MimeType"))
          return null;
        if (recArray.isDefined(insRow, "Name"))
          fileName = recArray.toString(insRow, "Name");
      } catch (RepositoryException e) {
        return null;
      }

      // If the name has no extension, pull the filename extension off
      // of the VersionInfo FileName.
      // TODO: The Livelink code uses a mimetype-to-extension map to do this.
      if (fileName != null && fileName.lastIndexOf('.') <= 0) {
        try {
          if (versionInfo == null)
            versionInfo = client.GetVersionInfo(volumeId, objectId, 0);
          if (versionInfo != null && versionInfo.hasValue()
              && versionInfo.isDefined("FileName")) {
            String fn = versionInfo.toString("FileName");
            int ext;
            if (fileName.trim().length() == 0)
              fileName = fn;
            else if ((ext = fn.lastIndexOf('.')) > 0)
              fileName += fn.substring(ext);
          }
        } catch (RepositoryException re) {
          // Object does not have VersionInfo or FileName property.
        }
      }

      // Now make the filename URL safe, repacing 'bad chars' with either
      // underscores or URL encoded escapes.
      if (fileName != null) {
        if (fileName.trim().length() > 0) {
          // Mangle filename like Livelink does.
          fileName = fileName.replaceAll("[\\s\\|\\?/\\\\<>;\\*%'\"]", "_");
          try {
            fileName = java.net.URLEncoder.encode(fileName, "UTF-8");
          } catch (java.io.UnsupportedEncodingException e) {
            // Shouldn't happen with UTF-8, but if it does, forget the filename.
            fileName = null;
          }
        } else {
          fileName = null;
        }
      }
      return fileName;
    }

    /**
     * Collect the content property for the item.
     * If the item does not (or must not) have content, then no
     * content property is generated.  If the item's content is
     * not acceptable according to the TraversalContext, then no
     * content is generated.  The content property's value is
     * inserted into the property map, but is also returned.
     *
     * @return content Value object, null if no content
     */
    private void collectContentProperty() throws RepositoryException {
      if (LOGGER.isLoggable(Level.FINER))
        LOGGER.finer("CONTENT WITH SUBTYPE = " + subType);

      // DataSize is the only non-nullable column from
      // DVersData that appears in the WebNodes view,
      // but there are cases (such as categories) where
      // there are rows in DVersData but FetchVersion
      // fails. So we're guessing here that if MimeType
      // is non-null then there should be a blob.
      ClientValue mimeType = recArray.toValue(insRow, "MimeType");
      if (!mimeType.isDefined())
        return;

      // XXX: This value might be wrong. There are
      // data size callbacks which can change this
      // value. For example, the value returned by
      // GetObjectInfo may be different than the
      // value retrieved from the database.
      int size = recArray.toInteger(insRow, "DataSize");
      if (LOGGER.isLoggable(Level.FINER))
        LOGGER.finer("CONTENT DATASIZE = " + size);
      if (size <= 0)
        return;

      // The TraversalContext Interface provides additional
      // screening based upon content size and mimetype.
      if (traversalContext != null) {
        // Is the content too large?
        if (((long) size) > traversalContext.maxDocumentSize())
          return;

        // Is this MimeType supported?
        String mt = mimeType.toString2();
        if (traversalContext.mimeTypeSupportLevel(mt) <= 0)
          return;
      } else {
        // If there is no traversal context, we'll enforce a
        // limit of 30 MB. This limit is hard-coded in the GSA
        // anyway.
        if (size > 30 * 1024 * 1024)
          return;
      }

      // If we pass the gauntlet, create a content stream property
      // and add it to the property map.
      InputStream is =
          contentHandler.getInputStream(volumeId, objectId, 0, size);
      Value contentValue = Value.getBinaryValue(is);
      props.addProperty(SpiConstants.PROPNAME_CONTENT, contentValue);
    }

    /**
     * Collect properties from the ExtendedData assoc.
     */
    private void collectExtendedDataProperties()
        throws RepositoryException {
      String[] fields = connector.getExtendedDataKeys(subType);
      if (fields == null)
        return;

      if (objectInfo == null)
        objectInfo = client.GetObjectInfo(volumeId, objectId);
      ClientValue extendedData = objectInfo.toValue("ExtendedData");
      if (extendedData == null || !extendedData.hasValue())
        return;

      // Make a set of the names in the assoc.
      HashSet<String> names = new HashSet<String>();
      Enumeration it = extendedData.enumerateNames();
      while (it.hasMoreElements())
        names.add((String) it.nextElement());

      // Decompose the ExtendedData into its atomic values,
      // and add them as properties.
      for (int i = 0; i < fields.length; i++) {
        if (names.contains(fields[i])) {
          ClientValue value = extendedData.toValue(fields[i]);
          if (value != null && value.hasValue()) {
            // XXX: For polls, the Questions field is a
            // stringified list of assoc. We're only handling
            // this one case, rather than handling stringified
            // values generally.
            if (subType == Client.POLLSUBTYPE &&
                fields[i].equals("Questions")) {
              value = value.stringToValue();
            }

            collectValueProperties(fields[i], value);
          }
        } else {
          if (LOGGER.isLoggable(Level.WARNING)) {
            LOGGER.warning("ExtendedData for " + objectId +
                " (subtype " + subType + ") has no " +
                fields[i] + " feature.");
          }
        }
      }
    }

    /**
     * Collects ObjectInfo properties.
     */
    private void collectObjectInfoProperties() throws RepositoryException {
      // First check for a request for ObjectInfo data.
      // By default, no ObjectInfo fields are specified.
      String[] fields = connector.getObjectInfoKeys();
      if (fields == null)
        return;

      if (objectInfo == null)
        objectInfo = client.GetObjectInfo(volumeId, objectId);
      if (objectInfo == null || !objectInfo.hasValue())
        return;

      // Extract the objectInfo items of interest and add them to the
      // property map.  We know there are no compound objectInfo fields.
      for (int i = 0; i < fields.length; i++) {
        ClientValue value = objectInfo.toValue(fields[i]);
        if (value != null && value.hasValue()) {
          // ExtendedData is the only non-atomic type, so explode it.
          // If the client specified it, slurp all the ExtendedData.
          // If the client wished to be more selective, they should
          // have added specific ExtendedData fields to the map.
          if ("ExtendedData".equalsIgnoreCase(fields[i]))
            collectValueProperties(fields[i], value);
          else if (isUserIdOrGroupId(fields[i]))
            addUserByName(fields[i], value);
          else
            props.addProperty(fields[i], value);
        }
      }
    }

    /**
     * Collects version properties.  We only support the "Current" version.
     */
    private void collectVersionProperties() throws RepositoryException {
      // First check for a request for version info data.
      // By default, no versionInfo fields are specified.
      String[] fields = connector.getVersionInfoKeys();
      if (fields == null)
        return;

      // Make sure this item has versions. See the MimeType, Version and
      // DataSize comments in collectContentProperty().  If DataSize is
      // not defined, then the item has no versions.
      ClientValue dataSize = recArray.toValue(insRow, "DataSize");
      if (!dataSize.isDefined())
        return;

      if (versionInfo == null)
        versionInfo = client.GetVersionInfo(volumeId, objectId, 0);
      if (versionInfo == null || !versionInfo.hasValue())
        return;

      // Extract the versionInfo items of interest and add them to the
      // property map.  We know there are no compound versionInfo fields.
      for (int i = 0; i < fields.length; i++) {
        ClientValue value = versionInfo.toValue(fields[i]);
        if (value != null && value.hasValue()) {
          if (isUserIdOrGroupId(fields[i]))
            addUserByName(fields[i], value);
          else
            props.addProperty(fields[i], value);
        }
      }
    }

    /**
     * Collects properties in an LLValue.
     *
     * @param name the property name
     * @param value the property value
     * @see #collectExtendedDataProperties
     */
    private void collectValueProperties(String name, ClientValue value)
        throws RepositoryException {
      if (LOGGER.isLoggable(Level.FINEST)) {
        LOGGER.finest("Type: " + value.type() + "; value: " +
            value.toString2());
      }

      switch (value.type()) {
        case ClientValue.LIST:
          for (int i = 0; i < value.size(); i++)
            collectValueProperties(name, value.toValue(i));
          break;

        case ClientValue.ASSOC:
          Enumeration keys = value.enumerateNames();
          while (keys.hasMoreElements()) {
            String key = (String) keys.nextElement();
            collectValueProperties(key, value.toValue(key));
          }
          break;

        case ClientValue.BOOLEAN:
        case ClientValue.DATE:
        case ClientValue.DOUBLE:
        case ClientValue.INTEGER:
        case ClientValue.STRING:
          props.addProperty(name, value);
          break;

        default:
          LOGGER.finest("Ignoring an unimplemented type.");
          break;
      }
    }

    /**
     * Gets the category attribute values for the indicated
     * object. Each attribute name is mapped to a linked list of
     * values for each occurrence of that attribute. Attributes
     * with the same name in different categories will have their
     * values merged into a single list. Attribute sets are
     * supported, but nested attribute sets are not. User
     * attributes are resolved into the user or group name.
     *
     * @throws RepositoryException if an error occurs
     */
    private void collectCategoryAttributes() throws RepositoryException {
      if (doCategories == false)
        return;

      // List the categories. LAPI requires us to use this
      // Assoc containing the id instead of just passing in
      // the id. The Assoc may have two other values, Type,
      // which specifies the kind of object being looked up
      // (there's only one legal value currently) and
      // Version, which specifies which version of the
      // object to use (the default is the current
      // version).
      ClientValue objIdAssoc = valueFactory.createAssoc();
      objIdAssoc.add("ID", objectId);
      ClientValue categoryIds = client.ListObjectCategoryIDs(objIdAssoc);

      // Loop over the categories.
      int numCategories = categoryIds.size();
      for (int i = 0; i < numCategories; i++) {
        ClientValue categoryId = categoryIds.toValue(i);

        // If this Category is not in the included list, or it is
        // explicitly mentioned in the excluded list, then skip it.
        Integer id = new Integer(categoryId.toInteger("ID"));
        if (((includedCategories != null) &&
                !includedCategories.contains(id)) ||
            ((excludedCategories != null) &&
                excludedCategories.contains(id)))
          continue;

        // Make sure we know what type of categoryId
        // object we have. There are also Workflow
        // category attributes which can't be read here.
        int categoryType = categoryId.toInteger("Type");
        if (Client.CATEGORY_TYPE_LIBRARY != categoryType) {
          if (LOGGER.isLoggable(Level.FINER)) {
            LOGGER.finer("Unknown category implementation type " +
                categoryType + "; skipping");
          }
          continue;
        }

        if (includeCategoryNames) {
          // XXX: This is the only property name that is
          // hard-coded here like this. I think that's OK,
          // because the recarray fields are just hard-coded
          // someplace else. "Category" is an ObjectInfo
          // attribute name that is unused in Livelink 9.0 or
          // later.
          ClientValue name = categoryId.toValue("DisplayName");
          if (name.hasValue())
            props.addProperty("Category", name);
        }

        ClientValue categoryVersion =
            client.GetObjectAttributesEx(objIdAssoc, categoryId);
        ClientValue attrNames =
            client.AttrListNames(categoryVersion, null);

        // Loop over the attributes for this category.
        int numAttributes = attrNames.size();
        for (int j = 0; j < numAttributes; j++) {
          String attrName = attrNames.toString(j);
          ClientValue attrInfo =
              client.AttrGetInfo(categoryVersion, attrName, null);
          int attrType = attrInfo.toInteger("Type");
          if (Client.ATTR_TYPE_SET == attrType)
            getAttributeSetValues(categoryVersion, attrName);
          else {
            getAttributeValue(categoryVersion, attrName,
                attrType, null, attrInfo);
          }
        }
      }
    }

    /**
     * Gets the values for attributes contained in an attribute set.
     *
     * @param categoryVersion the category being read
     * @param attributeName the name of the attribute set
     * @throws RepositoryException if an error occurs
     */
    private void getAttributeSetValues(ClientValue categoryVersion,
        String attrName) throws RepositoryException {
      // The "path" indicates the set attribute name to look
      // inside of in other methods like AttrListNames.
      ClientValue attrSetPath = valueFactory.createList();
      attrSetPath.add(attrName);

      // Get a list of the names of the attributes in the
      // set. Look up and store the types to avoid repeating
      // the type lookup when there's more than one instance of
      // the attribute set.
      ClientValue attrSetNames =
          client.AttrListNames(categoryVersion, attrSetPath);

      ClientValue[] attrInfo = new ClientValue[attrSetNames.size()];
      for (int i = 0; i < attrSetNames.size(); i++) {
        String name = attrSetNames.toString(i);
        attrInfo[i] = client.AttrGetInfo(categoryVersion, name, attrSetPath);
      }

      // List the values for the set attribute itself. There
      // may be multiple instances of the set.
      ClientValue setValues =
          client.AttrGetValues(categoryVersion, attrName, null);

      // Update the path to hold index of the set instance.
      attrSetPath.setSize(2);
      int numSets = setValues.size();
      for (int i = 0; i < numSets; i++) {
        attrSetPath.setInteger(1, i);
        // For each instance (row) of the attribute set, loop
        // over the attribute names.
        for (int j = 0; j < attrSetNames.size(); j++) {
          int type = attrInfo[j].toInteger("Type");
          if (Client.ATTR_TYPE_SET == type) {
            LOGGER.finer("Nested attributes sets are not supported.");
            continue;
          }
          //System.out.println("      " + attrSetNames.toString(j));
          getAttributeValue(categoryVersion, attrSetNames.toString(j),
              type, attrSetPath, attrInfo[j]);
        }
      }
    }

    /**
     * Gets the values for an attribute.
     *
     * @param categoryVersion the category version in which the
     * values are stored
     * @param attributeName the name of the attribute whose
     * values are being read
     * @param attributeType the type of the attribute data; may
     * not be "SET"
     * @param attributeSetPath if the attribute is contained
     * within an attribute set, this is a list containing the set
     * name and set instance index; otherwise, this should be
     * null
     * throws RepositoryException if an error occurs
     */
    private void getAttributeValue(ClientValue categoryVersion,
        String attrName, int attrType, ClientValue attrSetPath,
        ClientValue attrInfo) throws RepositoryException {

      if (Client.ATTR_TYPE_SET == attrType)
        throw new IllegalArgumentException("attrType = SET");

      // Maybe skip those attributes not marked as searchable.
      if ((includeSearchable) && !attrInfo.toBoolean("Search"))
        return;

      //System.out.println("getAttributeValue: attrName = " + attrName);

      ClientValue attrValues =
          client.AttrGetValues(categoryVersion, attrName, attrSetPath);

      // Even a simple attribute type can have multiple values
      // (displayed as rows in the Livelink UI).
      int numValues = attrValues.size();
      if (numValues == 0)
        return;

      // System.out.println("getAttributeValue: numValues = " +
      // numValues);

      for (int k = 0; k < numValues; k++) {
        ClientValue value = attrValues.toValue(k);
        // Avoid errors if the attribute hasn't been set.
        if (!value.hasValue())
          continue;
        // System.out.println("getAttributeValue: k = " + k +
        // " ; value = " + value.toString2());
        if (Client.ATTR_TYPE_USER == attrType)
          addUserByName(attrName, value);
        else
          props.addProperty(attrName, value);
      }
    }

    /**
     * Add a UserID or GroupID property value as the name of the user or
     * group, rather than the integral ID.
     *
     * @param propertyName  the property key to use when adding the value
     * to the map.
     * @param idValue ClientValue containing  the UserID or GroupID to
     * resolve to a name.
     */
    private void addUserByName(String propertyName, ClientValue idValue)
        throws RepositoryException {
      // If the UserID or GroupID is 0, then ignore it.
      // For reason why, see ObjectInfo Reserved and ReservedBy fields.
      int id = idValue.toInteger();
      if (id == 0)
        return;

      // Check the userName cache (if we recently looked up this user).
      ClientValue userName = userNameCache.get(new Integer(id));
      if (userName == null) {
        // User is not in the cache, get the name from the server.
        ClientValue userInfo = client.GetUserOrGroupByIDNoThrow(id);
        if (userInfo != null)
          userName = userInfo.toValue("Name");
        if (userName == null || !userName.isDefined()) {
          if (LOGGER.isLoggable(Level.WARNING)) {
            LOGGER.warning("No user or group name found for ID " + id);
          }
          return;
        }

        // If the cache was full, flush it. Not sophisticated MRU, but
        // good enough for our needs.
        if (userNameCache.size() > 100)
          userNameCache.clear();

        // Cache this userId to userName mapping for later reference.
        userNameCache.put(new Integer(id), userName);
      }

      // Finally, add the userName property to the map.
      props.addProperty(propertyName, userName);
    }

    /**
     * Determine whether this field contains a UserID or GroupID value.
     *
     * @param fieldName the name of the field in the ObjectInfo or
     * VersionInfo assoc.
     * @return true if the field contains a UserID or a GroupID value.
     */
    private boolean isUserIdOrGroupId(String fieldName) {
      for (int i = 0; i < USER_FIELD_NAMES.length; i++) {
        if (USER_FIELD_NAMES[i].equalsIgnoreCase(fieldName))
          return true;
      }
      return false;
    }
  }
}
